This article may contain URLs that were valid when originally published, but now link to sites or pages that no longer exist. To maintain the flow of the article, we've left these URLs in the text, but disabled the links.

MSDN Magazine

Extend the WSH Object Model with Custom Objects
Dino Esposito
Download the code for this article:Cutting1100.exe (85KB)
Browse the code for this article at Code Center:WSH Demo

H

ow can you import external code in your WSH scripts? I've been asked this question, in various forms, countless times. Let me say up front that this is not a hopelessly open issue. Microsoft has provided an effective, elegant answer with the new XML-based format of WSH 2.0. But in my experience, very few people seem to want to use the WSH 2.0 file format, WSF, preferring to stick with VBScript or JScript. This preference creates the need to import VBScript or JScript code, which can be a problem.
      There are several ways to accomplish this. I'll review a few of them and boldly go where no script programmer has gone before.
      In this article, I'll discuss commonly used techniques to import external code in VBScript and JScript® files and ways to extend the WSH object model with custom objects. These solutions work with Windows® 98, Windows 2000, Windows 95, and Windows NT® 4.0 with Microsoft Internet Explorer 4.01 or later installed.
      The first plan of attack is simple. You just decide which (root) objects you want to add to the object model and make your script code aware of them. First let's warm up by investigating the importing of external code.

Importing External Script Code

      Neither VBScript nor JScript defines language constructs that let you import external scripts in the same way HTML does (through the <SCRIPT src="�"> tag). For this reason, you must either upgrade to WSF files specific to WSH 2.0, or you must create a workaround. JScript has always provided the eval function that gives you the ability to evaluate code strings at runtime. This long-awaited feature was originally lacking in VBScript but has been available there since version 5.0. Now in both JScript and VBScript you can consider the source code to be a new data type that can be created and processed during execution time. This is particularly exciting in light of the VBScript 5.0 support for classes. If you cannot write classes in external files and reuse them without cutting and pasting, why write them?
      The eval function takes a code string as input, evaluates it and executes it. Adding this function to VBScript posed a problem: the VBScript syntax doesn't differentiate between comparison assignment operations. For example, the statement a = b can be a comparison, which returns a boolean value, or it can be a simple assignment operation. In JScript you use completely different operators (= versus ==), making your actual goal (evaluation or execution) unequivocally clear.
      The runtime code evaluation mechanism is crucial for importing code in plain VBScript and JScript scripts. In VBScript, for example, you can do this:

  Function Include(vbsFile)
Set fso = CreateObject("Scripting.FileSystemObject")
Set f = fso.OpenTextFile(vbsFile)
s = f.ReadAll()
f.Close
ExecuteGlobal s
End Function

 

      This function reads in the content of a VBScript file and adds it to the script's global namespace through the ExecuteGlobal statement. Using the global namespace means that the code isn't executed as part of a subroutine, and doesn't use local variables. VBScript provides another statement to execute code called Execute. As you may have guessed, this runs in the scope of the function that is calling it. Using Execute instead of ExecuteGlobal in the previous function would have made the code visible only within the body of the function Include!
      While not officially part of VBScript, this function, in all of its variations, is broadly accepted by the software community. You use it this way:

  Include "myfile.vbs"

'rest of the VBS file

' body of Include goes here
Function Include
•••
End Function

 

      In JScript things are a bit more complex. The eval function is functionally equivalent to VBScript Execute rather than ExecuteGlobal. This requires a slightly different approach.

  function Include(jsFile) {
fso = new ActiveXObject("Scripting.FileSystemObject");
f = fso.OpenTextFile(jsFile);
s = f.ReadAll();
f.Close();
return s;
}

 

      Now the Include function returns as a string the content of the JScript file that is being imported. This script code cannot be evaluated within the scope of Include; it needs to be evaluated where you want to include it:

  eval(Include("myfile.js"));

// rest of the JScript file

// body of the Include function
function Include(�) {�}

 

      You can do this to embed external functionality in your main script. For example, you can import the definition of a VBScript class and instantiate it through the New operator without worrying about its declaration.
      The solution works fine and will probably not be subject to future compatibility issues. Of course, if you use the XML-based format you can solve this problem in a much more elegant way. Any of the following declarations is acceptable:

  <script language="VBScript" src="myfile.vbs" />
<script language="JScript" src="myfile.js" />
<script src="myfile.js" />

 

      If you don't specify a scripting language, then the WSH runtime considers JScript to be the default, just as DHTML treats script blocks in HTML pages.
      Suppose you have a VBScript file with a single line like this:

  Set FileSystem = CreateObject("Scripting.FileSystemObject")
  

 

If you import it through Include, you can start using the FileSystem object variable in your code as if it were a native system object. The following code is perfectly legal:

  Include "globals.vbs"
MsgBox FileSystem.Drives.Count

' Include code

 

      If you're writing a lot of WSH script code, you may need to frequently reuse a certain subset of objects in various projects. On the other hand, the WSH object model is extremely lean and doesn't provide all the functionality you may need. Storing all instances of all the global objects in a single importable file is fine, but it requires that you either upgrade to the WSH 2.0 file format or use the Include function. However, there are two ways to avoid this. I'll examine two approaches to extending the standard set of WSH objects in plain VBScript or JScript without using helper functions.

Rewriting WSH from Scratch

      It goes almost without saying that if you rewrite the WSH executable from scratch you can make it support any object you want and publish any object model you like. WSH executables (wscript.exe in particular) are not terribly complex programs. In its simplest form, WSH is just a minimal program, minus a Windows UI, that knows how to process script code. Executing script code from within a compiled application is a matter of implementing a few COM interfaces if you're working with C++, or incorporating the Microsoft Script Control if you prefer Visual Basic.
      Let's see how to write a WSH type of program using only Visual Basic and the Script Control. Of course this won't be a complete replacement for WSH executables which are rich in functionality. However, the program I'm going to illustrate certainly embodies the essence of WSH and may constitute a good starting point for further experimentation.
      In Figure 1 you can see the Visual Basic environment at work. Notice the Script control icon and the invisible main form. Wscript.exe is not a console application, but it doesn't have a regular user interface made up of windows and common controls either. Everything takes place in the Form_Load event. It checks whether a file name has been specified on the command line. If no file has been indicated, the program terminates immediately. Next MyWSH, my script host program, checks for quotation marks in the file name and removes any that are found. Quotation marks can appear around a file name depending on how the MyWSH application is invoked. If you drag and drop file names onto it, or if you call it through the Run dialog, the name of the script file is usually wrapped by quotes to preserve long file names.
      Next, MyWSH checks the file extension and decides whether to set the language option of the Script control to VBScript or JScript. The Script control supports any registered script language for which you have a Windows Script compatible parser.
      At this point all the preliminaries have been completed. The code just loads the extra COM objects into the scripting global namespace, extracts the source code from the specified script file and executes it. In Figure 2 you can see a first draft of the necessary code.
      MyWSH supports a feature that you can only get in WSH by use of the <OBJECT> tag. It allows you to decide which objects to inject in the scripting namespace. In other words, by writing a global.asa type of file, you tell MyWSH which objects to instantiate and make available to the scripts. On a whim, I called this configuration file global.wsh. It looks like this:

  [Globals]
FileSystem=Scripting.FileSystemObject
Msg=ScriptToys.MsgBox

 

      The code on the left of the equal sign is the public name of the object. To the right of the equal sign is the progID of the object. Global.wsh must be in the same folder as the running script file. The code in Figure 2 extracts this information from the file and creates as many object instances as needed. Basically, the first line turns out to be:

  Dim obj As Object
Set obj = CreateObject("Scripting.FileSystemObject")
SC.AddObject "FileSystem", obj

 

      The AddObject method of the Script control has the ability to inject in the scripting global namespace an item called FileSystem powered by an instance of the Scripting.FileSystemObject object. At this point, any script fileâ€"whether it's written in VBScript, JScript or Perlâ€"sees and can utilize FileSystem as if it were a native language element. Incidentally, this is the same technique that WSH employs to give you the root WScript object. A VBScript file like this

  buf = ""
for each d in FileSystem.Drives
buf = buf & d.DriveLetter & " (" & d.VolumeName & ")" & vbCrLf
next

 

doesn't generate errors, and it works too!
      In reality it's unlikely that you need a brand new version of WSH, so I don't expect anyone to do this on a regular basis. The reason I presented this approach is that it represents a good way to make Visual Basic applications scriptable and to expose their own objects. If you did want to write your own scripting host, you would need to provide the same functionality as WSH 2.0 for the foundation. For example, support for the XML-based WSF file format and the same object model are important. The real problem is that the WScript root object is not externally creatable. All the other objects such as Shell, Network or Shortcut expose their progID and could be created without any problem. For example, if you modify my global.wsh file like this:

  [Globals]
FileSystem=Scripting.FileSystemObject
Msg=ScriptToys.MsgBox
Shell=WScript.Shell

 

then a VBScript file with only the following line is perfectly legal and works just fine:

  Shell.Run "notepad.exe"
  

 

      Writing a tool that replaces WSH poses another problem. You must modify some shell settings to make it automatically process both .vbs and .js files. This requires you to tweak the HKCR\VBSfile and HKCR\JSfile nodes in the registry. Figure 3 shows the standard content of the node HKCR\VBSfile. Just replace the name of the executable responsible for processing the VBS file. Change wscript.exe to MyWSH.exe (or whatever you named your scripting host) and you're finished. Leave the rest of the command line intact. The %1 indicates the name of the VBScript file, whereas the %* groups all the command-line parameters you want to pass to the VBS file upon execution. Before you do this, if you want to test MyWSH, just compile it and drag and drop a VBScript or JScript file onto it in Explorer. (See Figure 4.)

Figure 4 Dragging a VBS File to Execute
Figure 4 Dragging a VBS File to Execute

Forcing WSH to Use Extra Objects

      Unless you have very special needs, I don't recommend writing a new tool to replace WSH. Instead, you could force WSH to accept custom objects. Of course, this is not an issue at all if you choose to use the new format or decide to include external files as I demonstrated earlier. The following line

  <object id="FileSystem" progid="Scripting.FileSystemObject" />
  

 

creates an instance of the specified object and adds it to the scripting namespace. In WSH 2.0 you could simply add a reference to an object's type library without creating a brand new instance.

  <reference object="ADODB.Recordset" />
  

 

      For more information on the WSH 2.0 XML file format, refer to Cutting Edge in the September 1999 issue of Microsoft Internet Developer or take a look at the WSH help in MSDN Online.
      You may be wondering if you can "declaratively" instantiate and use these and other objects. Whether you use WSH 2.0 or VBScript or JScript, you have to explicitly create instances of the objects or import the code necessary for this. But can you simply declare which extra objects you want WSH to make available and with which names? In short, this is the essence of what I mean by extending the WSH object model with custom objects.
      As MTS and COM+ teach, the adverb "declaratively" means that you don't have to write code to reach your goal. Unfortunately, this is not the case in WSH. What you need to do is:

  1. Intercept any call to WSH
  2. Intercept the script code being executed
  3. Modify it on the fly by slipstreaming the code necessary to create object instances
  4. Ask WSH to run it for you

      How do you intercept any call to WSH that is made throughout the system? Actually, WSH is made up of two executables: wscript.exe and cscript.exeâ€"the latter being the stripped-down, console version of the former. Each time WSH processes a script, either one of these two programs is involved. I'll consider wscript.exe for simplicity's sake only. Everything I say applies to cscript.exe too. To hit the target, you need to detect any event that turns out to be a call to wscript.exe. Fortunately, this is exactly what the IShellExecuteHook interface does.

The IShellExecuteHook Interface

      IShellExecuteHook is a rather simple COM interface introduced with Internet Explorer 4.01 on all Win32®-based platforms except Windows CE. It was introduced in order to extend the behavior of the ShellExecute and ShellExecuteEx functions. Any time you invoke a shell object through either of these functions, the call is hooked by all registered shell extensions that provide this interface. A shell object can be a directory, a special folder, a namespace extension or a file. More importantly, ShellExecute and ShellExecuteEx are used extensively in Explorer. They are the recommended way to initiate new processes in Windows. Both functions end up calling CreateProcess, which is the only function that can start new processes in Win32. In addition, they accomplish a number of extra tasks and have a more flexible programming interface. They let you open and print documents, and enable more specific verbs on document classes. Unless you need to create processes exploiting the advanced features that CreateProcess makes available (debug mode, priority, environment settings, startup information, and the like), you should always use ShellExecute or the more powerful extended version, ShellExecuteEx.
      More and more, people are using ShellExecuteXX calls because they respect all restrictive policies set by system administrators. Through system policies, it's possible for administrators to decide which applications can't be started from Windows. ShellExecuteXX takes this blacklist into account, while CreateProcess doesn't.
      The Windows shell utilizes ShellExecuteEx to run executables, open registered documents, explore folders, print, find files, and much more. As a result, a ShellExecute hook is an extremely powerful tool to control what's going on in Windows. For your purposes, the IShellExecuteHook interface gives you a chance to intercept a call made to wscript.exe when the user double-clicks a link from Explorer, runs the script file from the Run dialog box, uses the command prompt, or takes advantage of third-party applications that run scripts through shell functions. There is just one case in which it doesn't work: when you run a script through a direct call to CreateProcess or WinExec.
      IShellExecuteHook has just one method, called Execute. The prototype looks like this:

  HRESULT Execute(LPSHELLEXECUTEINFO lpsei);
  

 

      To write a ShellExecute shell extension, start by writing an ATL object, and make sure it inherits from IShellExecuteHook as shown in Figure 5. You also must configure the registry to make the object behave like a ShellExecute shell extension. Add the following code to the ATL-generated RGS script:

  HKLM {
SOFTWARE {
Microsoft {
Windows {
CurrentVersion {
Explorer {
ShellExecuteHooks {
val {EB1FF7AC-C13F-4F12-9E75-902916F73501} = s 'WSH Interceptor'
}}}}}}}

 

Of course you should also make sure that the CLSID matches the CLSID of your shell extension object.

Intercepting WSH Calls

      Consider that all the registered ShellExecute hooks get called each time something happens within Explorer. Any call to a context menu initiates a call to the hook, as does a double-click to explore a folder. The reason for the call is found in the lpVerb member of the SHELLEXECUTEINFO structure that the method receives. This verb will either be set to "open" or will be an empty string if the user wants to run a script. Any other verb, such as edit, explore, or print, causes the extension to return without action.

  if (lstrlen(lpsei->lpVerb) && 
lstrcmpi(lpsei->lpVerb, _T("open")))
return S_FALSE;

 

      When you double-click on a VBScript or JScript file, or run it from the command prompt on the Start menu, the name of the executable contained in the lpFile field is the name of the script file. You should make sure that wscript.exe is really the program involved with the execution. FindExecutable is an API function that does this for you.

  TCHAR szExe[MAX_PATH];
FindExecutable(lpsei->lpFile, _T(""), szExe);

// Is it WSCRIPT?
LPTSTR pszWscript = NULL;
pszWscript = PathFindFileName(szExe);
if (strcmpi(pszWscript, _T("wscript.exe")))
return S_FALSE;

 

      This code snippet obtains the name of the executable that's expected to run the file, and verifies that it is wscript.exe. The use of PathFindFileName makes the presence of a path in the name transparent. Notice that if you pass an EXE file name to FindExecutable, it just returns that file name, so this code always works fine.
      The name of the script can be either a parameter (lpParameters field) or the executable itself (lpFile). Specifically, it is the file name itself if you double-click from the shell, while it is a parameter if you run the script from the Run dialog box. To grab the script file name, do the following:

  TCHAR szScriptFile[MAX_PATH];
if (lstrlen(lpsei->lpParameters) == 0)
lstrcpy(szScriptFile, lpsei->lpFile);
else
lstrcpy(szScriptFile, lpsei->lpParameters);

 

      You can only tell whether the file is a VBScript or a JScript file based on the file extension. Remember to make the file name all lower or uppercase since it's easier to work with case-sensitive string functions. In addition, remember that when the script file comes as a parameter, it may be enclosed in quotes. Just remove the quotation marks using the PathUnquoteSpaces function.

  strlwr(szScriptFile);
PathUnquoteSpaces(szScriptFile);

 

      The strategy to follow should now be clear. You read in the content of global.wsh, prepare the language-specific string that creates instances of all the objects declared, and slipstream this code at the beginning of the script file. Since I've deliberately given global.wsh an INI format, you can use GetPrivateProfileSection to read its contents in a single shot. Then, it's a matter of manipulating the string to figure out both the names and progIDs of the objects. Here's how you can prepare the code string to inject it into the original script file:

  switch(nLang)    {
case WSH_VBSCRIPT:
wsprintf(pszCode,
_T("set %s = CreateObject(\"%s\")\r\n"),
pszObj, pszProgID);
break;
case WSH_JSCRIPT:
wsprintf(pszCode,
_T("%s = new ActiveXObject(\"%s\");\r\n"),
pszObj, pszProgID);
break;
}

 

With a global.wsh like the one seen earlier, and with a VBScript file involved, the code to inject looks like this:

  Set FileSystem = CreateObject("Scripting.FileSystem")
Set Msg = CreateObject("ScriptToys.MsgBox")
Set Shell = CreateObject("WScript.Shell")

 

      You read the body of the script file and prefix it with the extra code. How do you run it now? You have two possibilities. The easy way is to persist the update to the script file and leave WSH to do its job. This approach has a serious disadvantage; it modifies the source code of the file. You could also write a temporary file and force WSH to run it instead of the original one. The problem here is less serious, but it is still a compromise. In fact, should the script need to test its file name (a rather infrequent occurrence, but it might happen) it would return the wrong name! To force WSH to run another file, just modify the lpFile or the lpParameters field of SHELLEXECUTEINFO accordingly.
      When the Execute method returns S_FALSE, Explorer interprets it as authorization to continue with the standard behavior. In the case of a VBScript or JScript file this means executing through WSH. However, if Execute returns S_OK, Explorer will consider the whole operation successfully terminated.
      You can then make a temporary copy of the original script, create a modified version of it with the same name, run the script yourself, and restore the original file upon completion. To make sure you know when the script processing has completed, you need to synchronize with the started process. The ability to synchronize is not something that ShellExecute offers. ShellExecuteEx, however, can be synchronized if you turn on the SEE_MASK_NOCLOSEPROCESS bit in the fMask field of the SHELLEXECUTEINFO structure. However, you cannot use ShellExecuteEx to run the script, otherwise you'll enter in a recursive loop because any call to ShellExecuteEx originates a new call to the hook function!
      The only possibility that remains is to use CreateProcess, since it allows synchronization and doesn't involve the ShellExecute hook.

  wsprintf(szCmdLine, _T("wscript.exe \"%s\""), 
szScriptFile);
CreateProcess(NULL, szCmdLine, NULL, NULL,
FALSE, NORMAL_PRIORITY_CLASS,
NULL, NULL, &si, &pi);
WaitForSingleObject(pi.hProcess, INFINITE);
DeleteFile(szScriptFile);
CopyFile(WSH_TEMP, szScriptFile, FALSE);
DeleteFile(WSH_TEMP);

 

The command line has the form of:

  wscript.exe "scriptfile"
  

 

      I'm using quotes to make sure the long file names aren't truncated. WaitForSingleObject synchronizes the execution of the current thread with the state of the specified kernel object. In this case, the object is the handle of the process just started. This means that the line after WaitForSingleObject executes only when the wscript.exe process has terminated. At that point, you can safely kill the modified script file, restore the original script file from the temporary copy, and finally delete the copy.

      If you don't set the hinstApp member of SHELLEXECUTEINFO to a value greater than 32 when you're about to return S_OK from Execute, you'll get a message from the shell. In practice, the hinstApp field is supposed to return the HINSTANCE of the started application to the application that called ShellExecute or ShellExecuteEx. This field normally holds the instance handle of the app that the function started. When you do this yourself and you don't set the field, the shell eagerly replies. Values below 32 are considered errors, whereas any greater value is fine. In Figure 6 you can see the complete source code for the Execute method.

Conclusion

      The mechanism based on IShellExecuteHook is completely transparent to scripts. Just install a COM component and double-click on a VBScript file. That's it. While I mostly worked with object instances in this sample code, there's nothing in particular that prevents you from inserting generic chunks of script.
      A global.wsh file can also help you declaratively customize an existing piece of script code by defining constants or global variables that affect the underlying code. I hope that in a future version of WSH Microsoft will address the issue, providing either a sort of global.asa for WSH or a standardized plug-in mechanism.

Dino Esposito is a trainer and consultant based in Rome. He is the author of several books for Wrox Press, and he now spends most of his time teaching classes on ASP+ and ADO+. Get in touch with Dino at desposito@vb2themax.com.

From the November 2000 issue of MSDN Magazine.